diff --git a/frontend/package.json b/frontend/package.json index 52c76e6..993d78f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@lit-labs/router": "^0.1.4", + "@lit/context": "^1.1.6", "lit": "^3.3.2", "urlpattern-polyfill": "^10.1.0" }, diff --git a/frontend/src/components/admin/profile/me-profile-certifications-editor.ts b/frontend/src/components/admin/profile/me-profile-certifications-editor.ts new file mode 100644 index 0000000..30f53e2 --- /dev/null +++ b/frontend/src/components/admin/profile/me-profile-certifications-editor.ts @@ -0,0 +1,167 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { adminFormStyles } from '../../../admin/admin-form-styles.js' +import type { MeCertification } from '../../../admin/types.js' +import '../ui/me-admin-panel.js' +import '../ui/me-admin-section.js' +import '../ui/me-text-input.js' + +@customElement('me-profile-certifications-editor') +export class MeProfileCertificationsEditor extends LitElement { + static formAssociated = true + + @property({ type: Array }) certifications: MeCertification[] = [] + @property() name = '' + + private _internals: ElementInternals + + constructor() { + super() + this._internals = this.attachInternals() + } + + private dispatchChange(next: MeCertification[]) { + this.certifications = next + this._internals.setFormValue(JSON.stringify(next)) + + this.dispatchEvent( + new CustomEvent('change', { + detail: next, + bubbles: true, + composed: true, + }), + ) + } + + updated(changedProperties: Map) { + if (changedProperties.has('certifications')) { + this._internals.setFormValue(JSON.stringify(this.certifications ?? [])) + } + } + + private addItem = () => { + const next = [ + ...this.certifications, + { name: '', issuer: '', year: new Date().getFullYear() }, + ] + this.dispatchChange(next) + } + + private removeItem(index: number) { + const next = this.certifications.filter((_, i) => i !== index) + this.dispatchChange(next) + } + + private updateItem(index: number, patch: Partial) { + const next = [...this.certifications] + next[index] = { ...next[index], ...patch } + this.dispatchChange(next) + } + + render() { + return html` + + + +
+ ${ + this.certifications.length === 0 + ? html`

資格がまだありません。

` + : this.certifications.map( + (cert, index) => html` + + + +
+ + this.updateItem(index, { name: e.detail })} + > + + + this.updateItem(index, { issuer: e.detail })} + > + + + this.updateItem(index, { + year: Number(e.detail || '0'), + })} + > + + + this.updateItem(index, { + month: e.detail ? Number(e.detail) : undefined, + })} + > +
+
+ `, + ) + } +
+
+ ` + } + + static styles = [ + adminFormStyles, + css` + :host { + display: block; + } + + .stack { + display: grid; + gap: 20px; + } + + .grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .empty-text { + color: var(--color-text-tertiary); + font-size: 14px; + font-style: italic; + } + `, + ] +} + +declare global { + interface HTMLElementTagNameMap { + 'me-profile-certifications-editor': MeProfileCertificationsEditor + } +} diff --git a/frontend/src/components/admin/profile/me-profile-experiences-editor.ts b/frontend/src/components/admin/profile/me-profile-experiences-editor.ts new file mode 100644 index 0000000..7914fea --- /dev/null +++ b/frontend/src/components/admin/profile/me-profile-experiences-editor.ts @@ -0,0 +1,168 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { adminFormStyles } from '../../../admin/admin-form-styles.js' +import type { MeExperience } from '../../../admin/types.js' +import '../ui/me-admin-panel.js' +import '../ui/me-admin-section.js' +import '../ui/me-text-input.js' + +@customElement('me-profile-experiences-editor') +export class MeProfileExperiencesEditor extends LitElement { + static formAssociated = true + + @property({ type: Array }) experiences: MeExperience[] = [] + @property() name = '' + + private _internals: ElementInternals + + constructor() { + super() + this._internals = this.attachInternals() + } + + private dispatchChange(next: MeExperience[]) { + this.experiences = next + this._internals.setFormValue(JSON.stringify(next)) + + this.dispatchEvent( + new CustomEvent('change', { + detail: next, + bubbles: true, + composed: true, + }), + ) + } + + updated(changedProperties: Map) { + if (changedProperties.has('experiences')) { + this._internals.setFormValue(JSON.stringify(this.experiences ?? [])) + } + } + + private addItem = () => { + const next = [ + ...this.experiences, + { company: '', url: '', startYear: new Date().getFullYear() }, + ] + this.dispatchChange(next) + } + + private removeItem(index: number) { + const next = this.experiences.filter((_, i) => i !== index) + this.dispatchChange(next) + } + + private updateItem(index: number, patch: Partial) { + const next = [...this.experiences] + next[index] = { ...next[index], ...patch } + this.dispatchChange(next) + } + + render() { + return html` + + + +
+ ${ + this.experiences.length === 0 + ? html`

経歴がまだありません。

` + : this.experiences.map( + (exp, index) => html` + + + +
+ + this.updateItem(index, { company: e.detail })} + > + + + this.updateItem(index, { url: e.detail })} + > + + + this.updateItem(index, { + startYear: Number(e.detail || '0'), + })} + > + + + this.updateItem(index, { + endYear: e.detail ? Number(e.detail) : undefined, + })} + > +
+
+ `, + ) + } +
+
+ ` + } + + static styles = [ + adminFormStyles, + css` + :host { + display: block; + } + + .stack { + display: grid; + gap: 20px; + } + + .grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .empty-text { + color: var(--color-text-tertiary); + font-size: 14px; + font-style: italic; + } + `, + ] +} + +declare global { + interface HTMLElementTagNameMap { + 'me-profile-experiences-editor': MeProfileExperiencesEditor + } +} diff --git a/frontend/src/components/admin/profile/me-profile-links-editor.ts b/frontend/src/components/admin/profile/me-profile-links-editor.ts new file mode 100644 index 0000000..8ad4346 --- /dev/null +++ b/frontend/src/components/admin/profile/me-profile-links-editor.ts @@ -0,0 +1,145 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { adminFormStyles } from '../../../admin/admin-form-styles.js' +import type { MeLink } from '../../../admin/types.js' +import '../ui/me-admin-panel.js' +import '../ui/me-admin-section.js' +import '../ui/me-text-input.js' + +@customElement('me-profile-links-editor') +export class MeProfileLinksEditor extends LitElement { + static formAssociated = true + + @property({ type: Array }) links: MeLink[] = [] + @property() name = '' + + private _internals: ElementInternals + + constructor() { + super() + this._internals = this.attachInternals() + } + + private dispatchChange(next: MeLink[]) { + this.links = next + this._internals.setFormValue(JSON.stringify(next)) + + this.dispatchEvent( + new CustomEvent('change', { + detail: next, + bubbles: true, + composed: true, + }), + ) + } + + updated(changedProperties: Map) { + if (changedProperties.has('links')) { + this._internals.setFormValue(JSON.stringify(this.links ?? [])) + } + } + + private addItem = () => { + const next = [...this.links, { platform: '', url: '' }] + this.dispatchChange(next) + } + + private removeItem(index: number) { + const next = this.links.filter((_, i) => i !== index) + this.dispatchChange(next) + } + + private updateItem(index: number, patch: Partial) { + const next = [...this.links] + next[index] = { ...next[index], ...patch } + this.dispatchChange(next) + } + + render() { + return html` + + + +
+ ${ + this.links.length === 0 + ? html`

リンクがまだありません。

` + : this.links.map( + (link, index) => html` + + + +
+ + this.updateItem(index, { platform: e.detail })} + > + + + this.updateItem(index, { url: e.detail })} + > +
+
+ `, + ) + } +
+
+ ` + } + + static styles = [ + adminFormStyles, + css` + :host { + display: block; + } + + .stack { + display: grid; + gap: 20px; + } + + .grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .empty-text { + color: var(--color-text-tertiary); + font-size: 14px; + font-style: italic; + } + `, + ] +} + +declare global { + interface HTMLElementTagNameMap { + 'me-profile-links-editor': MeProfileLinksEditor + } +} diff --git a/frontend/src/components/admin/profile/me-profile-skills-editor.ts b/frontend/src/components/admin/profile/me-profile-skills-editor.ts new file mode 100644 index 0000000..1bc3855 --- /dev/null +++ b/frontend/src/components/admin/profile/me-profile-skills-editor.ts @@ -0,0 +1,173 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { adminFormStyles } from '../../../admin/admin-form-styles.js' +import type { MeSkillGroup } from '../../../admin/types.js' +import '../ui/me-admin-panel.js' +import '../ui/me-admin-section.js' +import '../ui/me-text-input.js' +import '../ui/me-textarea.js' + +@customElement('me-profile-skills-editor') +export class MeProfileSkillsEditor extends LitElement { + static formAssociated = true + + @property({ type: Array }) skills: MeSkillGroup[] = [] + @property() name = '' + + private _internals: ElementInternals + + constructor() { + super() + this._internals = this.attachInternals() + } + + private dispatchChange(nextSkills: MeSkillGroup[]) { + this.skills = nextSkills + this._internals.setFormValue(JSON.stringify(nextSkills)) + + this.dispatchEvent( + new CustomEvent('change', { + detail: nextSkills, + bubbles: true, + composed: true, + }), + ) + } + + updated(changedProperties: Map) { + if (changedProperties.has('skills')) { + this._internals.setFormValue(JSON.stringify(this.skills ?? [])) + } + } + + private addSkill = () => { + const nextSkills = [ + ...this.skills, + { category: '', items: [], sortOrder: this.skills.length }, + ] + this.dispatchChange(nextSkills) + } + + private removeSkill(index: number) { + const nextSkills = this.skills.filter((_, i) => i !== index) + this.dispatchChange(nextSkills) + } + + private updateSkill(index: number, patch: Partial) { + const nextSkills = [...this.skills] + nextSkills[index] = { ...nextSkills[index], ...patch } + this.dispatchChange(nextSkills) + } + + private splitLines(value: string) { + return value + .split('\n') + .map((item) => item.trim()) + .filter(Boolean) + } + + render() { + return html` + + + +
+ ${ + this.skills.length === 0 + ? html`

まだ skill カテゴリがありません。

` + : this.skills.map( + (skill, index) => html` + + + +
+ + this.updateSkill(index, { category: e.detail })} + > + + + this.updateSkill(index, { + sortOrder: Number(e.detail || '0'), + })} + > + + + this.updateSkill(index, { + items: this.splitLines(e.detail), + })} + > +
+
+ `, + ) + } +
+
+ ` + } + + static styles = [ + adminFormStyles, + css` + :host { + display: block; + } + + .stack { + display: grid; + gap: 20px; + } + + .grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .field-wide { + grid-column: 1 / -1; + } + + .empty-text { + color: var(--color-text-tertiary); + font-size: 14px; + font-style: italic; + } + `, + ] +} + +declare global { + interface HTMLElementTagNameMap { + 'me-profile-skills-editor': MeProfileSkillsEditor + } +} diff --git a/frontend/src/components/admin/ui/me-admin-panel.ts b/frontend/src/components/admin/ui/me-admin-panel.ts new file mode 100644 index 0000000..3065123 --- /dev/null +++ b/frontend/src/components/admin/ui/me-admin-panel.ts @@ -0,0 +1,63 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' + +@customElement('me-admin-panel') +export class MeAdminPanel extends LitElement { + @property() title = '' + + render() { + return html` +
+
+

${this.title}

+
+ +
+
+
+ +
+
+ ` + } + + static styles = css` + :host { + display: block; + } + + .panel { + display: grid; + gap: 16px; + border: 1px solid var(--color-border-subtle); + background: var(--color-bg-surface); + padding: 20px; + } + + .header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + flex-wrap: wrap; + } + + .title { + font-size: 14px; + font-weight: 500; + color: var(--color-text-secondary); + margin: 0; + } + + .content { + display: grid; + gap: 16px; + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-admin-panel': MeAdminPanel + } +} diff --git a/frontend/src/components/admin/ui/me-admin-section.ts b/frontend/src/components/admin/ui/me-admin-section.ts new file mode 100644 index 0000000..0b6a6dc --- /dev/null +++ b/frontend/src/components/admin/ui/me-admin-section.ts @@ -0,0 +1,83 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' + +@customElement('me-admin-section') +export class MeAdminSection extends LitElement { + @property() title = '' + @property() description = '' + + render() { + return html` +
+
+
+

${this.title}

+ ${ + this.description + ? html`

${this.description}

` + : null + } +
+
+ +
+
+
+ +
+
+ ` + } + + static styles = css` + :host { + display: block; + } + + .section { + display: grid; + gap: 16px; + padding: 24px; + border: 1px solid var(--color-border); + background: var(--color-bg-surface); + } + + .header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + flex-wrap: wrap; + } + + .copy { + display: grid; + gap: 6px; + } + + .title { + font-size: 16px; + font-weight: 500; + color: var(--color-text-primary); + margin: 0; + } + + .description { + color: var(--color-text-tertiary); + font-size: 13px; + line-height: 1.8; + margin: 0; + } + + .content { + display: grid; + gap: 20px; + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-admin-section': MeAdminSection + } +} diff --git a/frontend/src/components/admin/ui/me-select.ts b/frontend/src/components/admin/ui/me-select.ts new file mode 100644 index 0000000..a5d1eea --- /dev/null +++ b/frontend/src/components/admin/ui/me-select.ts @@ -0,0 +1,145 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { classMap } from 'lit/directives/class-map.js' + +@customElement('me-select') +export class MeSelect extends LitElement { + static formAssociated = true + + @property() label = '' + @property() name = '' + @property() value = '' + @property({ type: Boolean }) disabled = false + @property({ type: Boolean }) required = false + + private _internals: ElementInternals + private _inputId = `me-input-${Math.random().toString(36).slice(2, 9)}` + + constructor() { + super() + this._internals = this.attachInternals() + } + + formResetCallback() { + this.value = '' + this._internals.setFormValue('') + } + + formDisabledCallback(disabled: boolean) { + this.disabled = disabled + } + + private _onChange(e: Event) { + const select = e.target as HTMLSelectElement + this.value = select.value + this._internals.setFormValue(select.value) + + this.dispatchEvent( + new CustomEvent('change', { + detail: select.value, + bubbles: true, + composed: true, + }), + ) + } + + updated(changedProperties: Map) { + if (changedProperties.has('value')) { + this._internals.setFormValue(this.value ?? '') + } + } + + render() { + return html` +
+ ${ + this.label + ? html`` + : null + } +
+ +
+
+ ` + } + + static styles = css` + :host { + display: block; + } + + .field { + display: grid; + gap: 6px; + } + + .label { + font-size: 13px; + font-weight: 400; + color: var(--color-text-secondary); + cursor: pointer; + } + + .select-wrapper { + position: relative; + display: grid; + } + + select { + width: 100%; + height: 40px; + padding: 0 32px 0 12px; + border: 1px solid var(--color-border); + background: #ffffff; + color: var(--color-text-primary); + font-family: inherit; + font-size: 14px; + border-radius: 4px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: none; + appearance: none; + cursor: pointer; + } + + .select-wrapper::after { + content: ""; + position: absolute; + right: 12px; + top: 50%; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid var(--color-text-tertiary); + transform: translateY(-50%); + pointer-events: none; + } + + select:focus { + border-color: var(--admin-accent); + box-shadow: 0 0 0 1px var(--admin-accent); + } + + select:disabled { + background: var(--color-bg-deep); + color: var(--color-text-tertiary); + cursor: not-allowed; + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-select': MeSelect + } +} diff --git a/frontend/src/components/admin/ui/me-text-input.ts b/frontend/src/components/admin/ui/me-text-input.ts new file mode 100644 index 0000000..8bce5ca --- /dev/null +++ b/frontend/src/components/admin/ui/me-text-input.ts @@ -0,0 +1,147 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { classMap } from 'lit/directives/class-map.js' + +@customElement('me-text-input') +export class MeTextInput extends LitElement { + static formAssociated = true + + @property() label = '' + @property() name = '' + @property() autocomplete = '' + @property() value: string | number = '' + @property() type: + | 'text' + | 'number' + | 'email' + | 'password' + | 'url' + | 'datetime-local' + | 'search' = 'text' + @property({ type: Boolean }) disabled = false + @property({ type: Boolean }) required = false + @property({ type: Boolean }) readonly = false + @property() placeholder = '' + + private _internals: ElementInternals + private _inputId = `me-input-${Math.random().toString(36).slice(2, 9)}` + + constructor() { + super() + this._internals = this.attachInternals() + } + + focus(options?: FocusOptions) { + this.shadowRoot?.querySelector('input')?.focus(options) + } + + // Native form callbacks + formResetCallback() { + this.value = '' + this._internals.setFormValue('') + } + + formDisabledCallback(disabled: boolean) { + this.disabled = disabled + } + + private _onInput(e: Event) { + const input = e.target as HTMLInputElement + this.value = input.value + this._internals.setFormValue(input.value) + + // We still dispatch a change event for convenience, + // but the form now sees the value automatically. + this.dispatchEvent( + new CustomEvent('change', { + detail: input.value, + bubbles: true, + composed: true, + }), + ) + } + + updated(changedProperties: Map) { + if (changedProperties.has('value')) { + this._internals.setFormValue(String(this.value ?? '')) + } + } + + render() { + return html` +
+ ${ + this.label + ? html`` + : null + } + +
+ ` + } + + static styles = css` + :host { + display: block; + } + + .field { + display: grid; + gap: 6px; + } + + .label { + font-size: 13px; + font-weight: 400; + color: var(--color-text-secondary); + cursor: pointer; + } + + input { + width: 100%; + height: 40px; + padding: 0 12px; + border: 1px solid var(--color-border); + background: #ffffff; + color: var(--color-text-primary); + font-family: inherit; + font-size: 14px; + border-radius: 4px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: none; + } + + input:focus { + border-color: var(--admin-accent); + box-shadow: 0 0 0 1px var(--admin-accent); + } + + input:disabled { + background: var(--color-bg-deep); + color: var(--color-text-tertiary); + cursor: not-allowed; + } + + input[readonly] { + background: var(--color-bg-dim); + border-color: var(--color-border-subtle); + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-text-input': MeTextInput + } +} diff --git a/frontend/src/components/admin/ui/me-textarea.ts b/frontend/src/components/admin/ui/me-textarea.ts new file mode 100644 index 0000000..2b1a1ef --- /dev/null +++ b/frontend/src/components/admin/ui/me-textarea.ts @@ -0,0 +1,125 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { classMap } from 'lit/directives/class-map.js' + +@customElement('me-textarea') +export class MeTextarea extends LitElement { + static formAssociated = true + + @property() label = '' + @property() name = '' + @property() value = '' + @property({ type: Number }) rows = 4 + @property({ type: Boolean }) disabled = false + @property({ type: Boolean }) required = false + @property() placeholder = '' + + private _internals: ElementInternals + private _inputId = `me-input-${Math.random().toString(36).slice(2, 9)}` + + constructor() { + super() + this._internals = this.attachInternals() + } + + formResetCallback() { + this.value = '' + this._internals.setFormValue('') + } + + formDisabledCallback(disabled: boolean) { + this.disabled = disabled + } + + private _onInput(e: Event) { + const input = e.target as HTMLTextAreaElement + this.value = input.value + this._internals.setFormValue(input.value) + + this.dispatchEvent( + new CustomEvent('change', { + detail: input.value, + bubbles: true, + composed: true, + }), + ) + } + + updated(changedProperties: Map) { + if (changedProperties.has('value')) { + this._internals.setFormValue(this.value ?? '') + } + } + + render() { + return html` +
+ ${ + this.label + ? html`` + : null + } + +
+ ` + } + + static styles = css` + :host { + display: block; + } + + .field { + display: grid; + gap: 6px; + } + + .label { + font-size: 13px; + font-weight: 400; + color: var(--color-text-secondary); + cursor: pointer; + } + + textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border); + background: #ffffff; + color: var(--color-text-primary); + font-family: inherit; + font-size: 14px; + line-height: 1.6; + border-radius: 4px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: none; + resize: vertical; + } + + textarea:focus { + border-color: var(--admin-accent); + box-shadow: 0 0 0 1px var(--admin-accent); + } + + textarea:disabled { + background: var(--color-bg-deep); + color: var(--color-text-tertiary); + cursor: not-allowed; + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-textarea': MeTextarea + } +} diff --git a/frontend/src/components/app-admin-shell.ts b/frontend/src/components/app-admin-shell.ts index ef1ba77..e92da03 100644 --- a/frontend/src/components/app-admin-shell.ts +++ b/frontend/src/components/app-admin-shell.ts @@ -1,13 +1,27 @@ +import { consume } from '@lit/context' import { css, html, LitElement } from 'lit' import { customElement, property } from 'lit/decorators.js' import { classMap } from 'lit/directives/class-map.js' +import { authContext } from '../contexts/auth-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import type { IAuthRepository } from '../domain/AuthRepository.js' import type { RouteShellElement } from './route-shell.js' import { playLeaveTransition, routeShellStyles } from './route-shell.js' @customElement('app-admin-shell') export class AppAdminShell extends LitElement implements RouteShellElement { - @property({ type: Boolean }) - authenticated = false + @consume({ context: authContext, subscribe: true }) + set authRepo(repo: IAuthRepository | undefined) { + if (this._authRepo === repo) return + this._authRepo = repo + if (this._observer) this._observer.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get authRepo() { + return this._authRepo + } + private _authRepo?: IAuthRepository + private _observer?: RepositoryObserver @property() currentPath = '/admin' @@ -16,10 +30,11 @@ export class AppAdminShell extends LitElement implements RouteShellElement { busy = false render() { + const authenticated = this.authRepo?.status === 'authenticated' return html` -
+
${ - this.authenticated + authenticated ? html`
- - -
@@ -134,19 +114,14 @@ export class PageAdminAccount extends LitElement { ` } - private handleLogout = () => { + private handleLogout = async () => { if (!window.confirm('現在の端末からログアウトします。よろしいですか?')) return - this.dispatchEvent( - new CustomEvent('admin-logout', { - bubbles: true, - composed: true, - }), - ) + await this.authRepo.logout() } - private handleRevokeAllSessions = () => { + private handleRevokeAllSessions = async () => { if ( !window.confirm( 'すべてのセッションを終了します。現在の端末も再ログインが必要になります。実行しますか?', @@ -155,30 +130,22 @@ export class PageAdminAccount extends LitElement { return } - this.dispatchEvent( - new CustomEvent('admin-revoke-sessions', { - bubbles: true, - composed: true, - }), - ) + await this.authRepo.revokeAllSessions() } - private handleChangeEmail(event: Event) { + private async handleChangeEmail(event: Event) { event.preventDefault() - this.lastSubmittedAction = 'change-email' + const form = event.target as HTMLFormElement + const formData = new FormData(form) - const detail: ChangeEmailInput = { - token: this.token.trim(), - newEmailAddress: this.newEmailAddress.trim(), - } + await this.authRepo.changeEmail({ + token: (formData.get('token') as string).trim(), + newEmailAddress: (formData.get('newEmailAddress') as string).trim(), + }) - this.dispatchEvent( - new CustomEvent('admin-change-email', { - detail, - bubbles: true, - composed: true, - }), - ) + if (!this.authRepo.accountError) { + form.reset() + } } static styles = [ diff --git a/frontend/src/pages/page-admin-articles.ts b/frontend/src/pages/page-admin-articles.ts index 1cd7b04..55847ab 100644 --- a/frontend/src/pages/page-admin-articles.ts +++ b/frontend/src/pages/page-admin-articles.ts @@ -1,3 +1,4 @@ +import { consume } from '@lit/context' import { css, html, LitElement } from 'lit' import { customElement, state } from 'lit/decorators.js' import { adminFormStyles } from '../admin/admin-form-styles.js' @@ -19,6 +20,15 @@ import { createEmptyArticleDraft, } from '../admin/article-types.js' import { describeApiError } from '../admin/types.js' +import { articleContext } from '../contexts/article-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import type { IArticleRepository } from '../domain/ArticleRepository.js' + +// Import encapsulated components +import '../components/admin/ui/me-admin-section.js' +import '../components/admin/ui/me-text-input.js' +import '../components/admin/ui/me-textarea.js' +import '../components/admin/ui/me-select.js' interface SearchFormState { q: string @@ -36,6 +46,19 @@ const createSearchFormState = (): SearchFormState => ({ @customElement('page-admin-articles') export class PageAdminArticles extends LitElement { + @consume({ context: articleContext, subscribe: true }) + set articleRepo(repo: IArticleRepository) { + if (this._articleRepo === repo) return + this._articleRepo = repo + if (this._observer) this._observer.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get articleRepo() { + return this._articleRepo + } + private _articleRepo!: IArticleRepository + private _observer?: RepositoryObserver + @state() private articles: ArticleItem[] = [] @@ -69,17 +92,14 @@ export class PageAdminArticles extends LitElement { @state() private editorMode: 'create' | 'edit' = 'create' - @state() - private form: ArticleDraft = createEmptyArticleDraft() - @state() private baseline: ArticleDraft = createEmptyArticleDraft() @state() - private isDirty = false + private localDirty = false private onBeforeUnload = (event: BeforeUnloadEvent) => { - if (!this.isDirty) return + if (!this.articleRepo.adminDirty && !this.localDirty) return event.preventDefault() event.returnValue = '' } @@ -99,6 +119,7 @@ export class PageAdminArticles extends LitElement { } render() { + const ac = this.articleRepo return html`
` } + private handleInput() { + this.localDirty = true + this.articleRepo.setAdminDirty(true) + } + private async loadInitialData() { this.loading = true this.errorMessage = '' @@ -595,6 +540,16 @@ export class PageAdminArticles extends LitElement { private handleSearch(event: Event) { event.preventDefault() + const form = event.target as HTMLFormElement + const formData = new FormData(form) + + this.filters = { + ...this.filters, + q: (formData.get('q') as string) || '', + year: (formData.get('year') as string) || '', + platform: (formData.get('platform') as SearchFormState['platform']) || '', + } + void this.reloadArticles() } @@ -637,22 +592,43 @@ export class PageAdminArticles extends LitElement { private async handleSubmit(event: Event) { event.preventDefault() + const form = event.target as HTMLFormElement + if (!form.checkValidity()) { + form.reportValidity() + return + } + + const formData = new FormData(form) + const draft: ArticleDraft = { + externalId: formData.get('externalId') as string, + platform: formData.get('platform') as ArticlePlatform, + title: formData.get('title') as string, + url: formData.get('url') as string, + publishedAt: (formData.get('publishedAt') as string) || '', + articleUpdatedAt: (formData.get('articleUpdatedAt') as string) || '', + tags: ((formData.get('tags') as string) || '') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean), + } + this.saving = true this.errorMessage = '' this.successMessage = '' try { if (this.editorMode === 'edit') { - await updateArticle(this.form.externalId, this.form) + await updateArticle(draft.externalId, draft) this.successMessage = '記事を更新しました。' } else { - await createArticle(this.form) + await createArticle(draft) this.editorMode = 'edit' this.successMessage = '記事を登録しました。' } - this.setBaseline(cloneArticleDraft(this.form)) + this.setBaseline(cloneArticleDraft(draft)) await Promise.all([this.reloadArticles(), this.refreshTags()]) + this.localDirty = false } catch (error) { this.errorMessage = describeApiError(error) } finally { @@ -669,7 +645,7 @@ export class PageAdminArticles extends LitElement { this.successMessage = '' try { - await deleteArticle(this.form.externalId) + await deleteArticle(this.baseline.externalId) this.successMessage = '記事を削除しました。' this.startCreateMode() await Promise.all([this.reloadArticles(), this.refreshTags()]) @@ -680,9 +656,12 @@ export class PageAdminArticles extends LitElement { } } - private handleReset = () => { + private handleReset = (e: Event) => { + e.preventDefault() if (!this.confirmDiscardChanges()) return - this.setForm(cloneArticleDraft(this.baseline)) + this.localDirty = false + this.articleRepo.setAdminDirty(false) + this.requestUpdate() } private startCreateMode() { @@ -690,6 +669,8 @@ export class PageAdminArticles extends LitElement { this.setBaseline(createEmptyArticleDraft()) this.successMessage = '' this.errorMessage = '' + this.localDirty = false + this.articleRepo.setAdminDirty(false) } private startEditMode(article: ArticleItem) { @@ -697,59 +678,21 @@ export class PageAdminArticles extends LitElement { this.setBaseline(articleDraftFromArticle(article)) this.successMessage = '' this.errorMessage = '' - } - - private updateForm( - key: Key, - value: ArticleDraft[Key], - ) { - this.setForm({ - ...this.form, - [key]: value, - }) + this.localDirty = false + this.articleRepo.setAdminDirty(false) } private setBaseline(nextBaseline: ArticleDraft) { this.baseline = nextBaseline - this.setForm(cloneArticleDraft(nextBaseline)) - } - - private setForm(nextForm: ArticleDraft) { - this.form = nextForm - this.updateDirtyState(nextForm) - } - - private updateDirtyState(nextForm: ArticleDraft) { - const nextDirty = JSON.stringify(nextForm) !== JSON.stringify(this.baseline) - if (this.isDirty === nextDirty) return - - this.isDirty = nextDirty - if (nextDirty) { - this.successMessage = '' - } - this.dispatchEvent( - new CustomEvent('admin-articles-dirty-change', { - detail: nextDirty, - bubbles: true, - composed: true, - }), - ) } private confirmDiscardChanges() { return ( - !this.isDirty || + (!this.articleRepo.adminDirty && !this.localDirty) || window.confirm('未保存の変更を破棄して切り替えてもよいですか?') ) } - private splitLines(value: string) { - return value - .split('\n') - .map((item) => item.trim()) - .filter(Boolean) - } - private toOptionalNumber(value: string) { const trimmed = value.trim() return trimmed === '' ? undefined : Number(trimmed) @@ -789,7 +732,6 @@ export class PageAdminArticles extends LitElement { } .page-header, - .section-header, .article-card-header, .actions { display: flex; @@ -821,7 +763,6 @@ export class PageAdminArticles extends LitElement { font-size: 12px; } - .section, .article-card { display: grid; gap: 16px; @@ -830,12 +771,6 @@ export class PageAdminArticles extends LitElement { background: #fff; } - .section-copy { - display: grid; - gap: 6px; - } - - .section-help, .loading, .muted { color: var(--color-text-tertiary); @@ -863,6 +798,7 @@ export class PageAdminArticles extends LitElement { color: var(--color-text-secondary); padding: 6px 10px; font-size: 12px; + cursor: pointer; } .tag-chip.selected { @@ -895,6 +831,10 @@ export class PageAdminArticles extends LitElement { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } + .field-wide { + grid-column: 1 / -1; + } + .empty-panel { display: grid; gap: 12px; @@ -938,6 +878,7 @@ export class PageAdminArticles extends LitElement { border: 1px solid var(--color-border); background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(12px); + z-index: 10; } .actions-copy { @@ -953,12 +894,6 @@ export class PageAdminArticles extends LitElement { color: #9a6d2f; } - h2 { - font-size: 16px; - font-weight: 500; - color: var(--color-text-primary); - } - @media (max-width: 1080px) { .content-grid { grid-template-columns: 1fr; diff --git a/frontend/src/pages/page-admin-login.ts b/frontend/src/pages/page-admin-login.ts index bceca90..2fa9d5d 100644 --- a/frontend/src/pages/page-admin-login.ts +++ b/frontend/src/pages/page-admin-login.ts @@ -1,35 +1,38 @@ +import { consume } from '@lit/context' import { css, html, LitElement } from 'lit' -import { customElement, property, state } from 'lit/decorators.js' +import { customElement, state } from 'lit/decorators.js' import { adminFormStyles } from '../admin/admin-form-styles.js' -import type { AdminLoginInput } from '../admin/types.js' +import { authContext } from '../contexts/auth-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import type { IAuthRepository } from '../domain/AuthRepository.js' +import '../components/admin/ui/me-text-input.js' @customElement('page-admin-login') export class PageAdminLogin extends LitElement { - @property({ type: Boolean }) - submitting = false - - @property() - errorMessage = '' - - @property() - noticeMessage = '' - - @state() - private emailAddress = '' - - @state() - private password = '' + @consume({ context: authContext, subscribe: true }) + set authRepo(repo: IAuthRepository) { + if (this._authRepo === repo) return + this._authRepo = repo + if (this._observer) this._observer.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get authRepo() { + return this._authRepo + } + private _authRepo!: IAuthRepository + private _observer?: RepositoryObserver @state() private passwordVisible = false firstUpdated() { this.shadowRoot - ?.querySelector('input[name="emailAddress"]') + ?.querySelector('me-text-input[name="emailAddress"]') ?.focus() } render() { + const a = this.authRepo return html`
@@ -40,56 +43,48 @@ export class PageAdminLogin extends LitElement {

${ - this.noticeMessage - ? html`

${this.noticeMessage}

` + a.loginNotice + ? html`

${a.loginNotice}

` : null }
-
@@ -97,33 +92,19 @@ export class PageAdminLogin extends LitElement { ` } - private handleEmailInput(event: Event) { - this.emailAddress = (event.target as HTMLInputElement).value - } - - private handlePasswordInput(event: Event) { - this.password = (event.target as HTMLInputElement).value - } - private togglePasswordVisibility = () => { this.passwordVisible = !this.passwordVisible } - private handleSubmit(event: Event) { + private async handleSubmit(event: Event) { event.preventDefault() + const form = event.target as HTMLFormElement + const formData = new FormData(form) - const detail: AdminLoginInput = { - emailAddress: this.emailAddress.trim(), - password: this.password, - } - - this.dispatchEvent( - new CustomEvent('admin-login-submit', { - detail, - bubbles: true, - composed: true, - }), - ) + await this.authRepo.login({ + emailAddress: (formData.get('emailAddress') as string).trim(), + password: formData.get('password') as string, + }) } static styles = [ @@ -157,15 +138,21 @@ export class PageAdminLogin extends LitElement { gap: 18px; } - .password-field { + .password-field-container { + position: relative; display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: center; } - .subtle { - min-width: 72px; + .password-toggle { + position: absolute; + right: 0; + top: 0; + height: 20px; + font-size: 12px; + } + + button[type="submit"] { + margin-top: 8px; } `, ] diff --git a/frontend/src/pages/page-admin-profile.ts b/frontend/src/pages/page-admin-profile.ts index a8b3ed1..e6b242b 100644 --- a/frontend/src/pages/page-admin-profile.ts +++ b/frontend/src/pages/page-admin-profile.ts @@ -1,41 +1,41 @@ +import { consume } from '@lit/context' import { css, html, LitElement } from 'lit' -import { customElement, property, state } from 'lit/decorators.js' +import { customElement, state } from 'lit/decorators.js' import { adminFormStyles } from '../admin/admin-form-styles.js' -import { - cloneMeProfile, - createEmptyMeProfile, - type MeCertification, - type MeExperience, - type MeLink, - type MeProfile, - type MeSkillGroup, -} from '../admin/types.js' +import { type MeProfile } from '../admin/types.js' +import { profileContext } from '../contexts/profile-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import type { IProfileRepository } from '../domain/ProfileRepository.js' + +// Import encapsulated components +import '../components/admin/ui/me-admin-section.js' +import '../components/admin/ui/me-text-input.js' +import '../components/admin/ui/me-textarea.js' +import '../components/admin/profile/me-profile-skills-editor.js' +import '../components/admin/profile/me-profile-certifications-editor.js' +import '../components/admin/profile/me-profile-experiences-editor.js' +import '../components/admin/profile/me-profile-links-editor.js' @customElement('page-admin-profile') export class PageAdminProfile extends LitElement { - @property({ attribute: false }) - profile: MeProfile = createEmptyMeProfile() - - @property({ type: Boolean }) - loading = false - - @property({ type: Boolean }) - saving = false - - @property() - errorMessage = '' - - @property() - successMessage = '' - - @state() - private form: MeProfile = createEmptyMeProfile() + @consume({ context: profileContext, subscribe: true }) + set profileRepo(repo: IProfileRepository) { + if (this._profileRepo === repo) return + this._profileRepo = repo + if (this._observer) this._observer.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get profileRepo() { + return this._profileRepo + } + private _profileRepo!: IProfileRepository + private _observer?: RepositoryObserver @state() - private isDirty = false + private localDirty = false private onBeforeUnload = (event: BeforeUnloadEvent) => { - if (!this.isDirty) return + if (!this.profileRepo.adminDirty && !this.localDirty) return event.preventDefault() event.returnValue = '' } @@ -50,487 +50,123 @@ export class PageAdminProfile extends LitElement { window.removeEventListener('beforeunload', this.onBeforeUnload) } - protected willUpdate(changedProperties: Map) { - if (changedProperties.has('profile')) { - this.setForm(cloneMeProfile(this.profile)) - } - } - render() { + const p = this.profileRepo + const profile = p.adminProfile + return html`
- ${this.errorMessage ? html`

${this.errorMessage}

` : null} + ${p.adminError ? html`

${p.adminError}

` : null} ${ - this.successMessage - ? html`

${this.successMessage}

` + p.adminSuccess + ? html`

${p.adminSuccess}

` : null } ${ - this.loading + p.adminLoading ? html`

プロフィールを読み込み中...

` : html` -
-
-
-

基本情報

-

- 最低限、表示名だけあれば更新できます。未入力項目は公開画面で省略されます。 -

-
+ +
- - - - + + + + + + +
-
- -
-
-
-

Skills

-

- カテゴリごとに整理し、Items は1行ずつ入力すると編集しやすいです。 -

-
- -
-
- ${ - this.form.skills.length === 0 - ? this.renderEmptyPanel( - 'まだ skill カテゴリがありません。', - 'カテゴリを追加', - this.addSkill, - ) - : this.form.skills.map( - (skill, index) => html` -
-
-

カテゴリ ${index + 1}

- -
-
- - - -
-
- `, - ) - } -
-
- -
-
-
-

Certifications

-

- month は任意です。年だけでも掲載できます。 -

-
- -
-
- ${ - this.form.certifications.length === 0 - ? this.renderEmptyPanel( - '資格がまだありません。', - '資格を追加', - this.addCertification, - ) - : this.form.certifications.map( - (certification, index) => html` -
-
-

資格 ${index + 1}

- -
-
- - - - -
-
- `, - ) - } -
-
- -
-
-
-

Experiences

-

- endYear を空にすると、継続中の経歴として扱えます。 -

-
- -
-
- ${ - this.form.experiences.length === 0 - ? this.renderEmptyPanel( - '経歴がまだありません。', - '経歴を追加', - this.addExperience, - ) - : this.form.experiences.map( - (experience, index) => html` -
-
-

経歴 ${index + 1}

- -
-
- - - - -
-
- `, - ) - } -
-
- -
-
-
-

Links

-

- platform と URL は必須です。label は公開側で見せたい名前を指定します。 -

-
- -
-
- ${ - this.form.links.length === 0 - ? this.renderEmptyPanel( - 'リンクがまだありません。', - 'リンクを追加', - this.addLink, - ) - : this.form.links.map( - (link, index) => html` -
-
-

リンク ${index + 1}

- -
-
- - -
-
- `, - ) - } -
-
- -
-
-

Likes

-

- 1行ごとに1件ずつ入力します。空行は保存時に除外されます。 -

-
- -
+ + + + + + + + + + + + +
-

- ${this.isDirty ? '未保存の変更があります。' : '保存済みの内容です。'} +

+ ${ + p.adminDirty || this.localDirty + ? '未保存の変更があります。' + : '保存済みの内容です。' + }

-
@@ -540,173 +176,54 @@ export class PageAdminProfile extends LitElement { ` } - private updateField( - key: Key, - value: MeProfile[Key], - ) { - this.setForm({ - ...this.form, - [key]: value, - }) - } - - private updateSkill(index: number, patch: Partial) { - const skills = [...this.form.skills] - skills[index] = { ...skills[index], ...patch } - this.updateField('skills', skills) - } - - private updateCertification(index: number, patch: Partial) { - const certifications = [...this.form.certifications] - certifications[index] = { ...certifications[index], ...patch } - this.updateField('certifications', certifications) - } - - private updateExperience(index: number, patch: Partial) { - const experiences = [...this.form.experiences] - experiences[index] = { ...experiences[index], ...patch } - this.updateField('experiences', experiences) - } - - private updateLink(index: number, patch: Partial) { - const links = [...this.form.links] - links[index] = { ...links[index], ...patch } - this.updateField('links', links) - } - - private addSkill = () => { - this.updateField('skills', [ - ...this.form.skills, - { category: '', items: [], sortOrder: this.form.skills.length }, - ]) - } - - private removeSkill(index: number) { - this.updateField( - 'skills', - this.form.skills.filter((_, itemIndex) => itemIndex !== index), - ) - } - - private addCertification = () => { - this.updateField('certifications', [ - ...this.form.certifications, - { - name: '', - issuer: '', - year: new Date().getFullYear(), - }, - ]) - } - - private removeCertification(index: number) { - this.updateField( - 'certifications', - this.form.certifications.filter((_, itemIndex) => itemIndex !== index), - ) - } - - private addExperience = () => { - this.updateField('experiences', [ - ...this.form.experiences, - { - company: '', - url: '', - startYear: new Date().getFullYear(), - }, - ]) - } - - private removeExperience(index: number) { - this.updateField( - 'experiences', - this.form.experiences.filter((_, itemIndex) => itemIndex !== index), - ) - } - - private addLink = () => { - this.updateField('links', [ - ...this.form.links, - { - platform: '', - url: '', - }, - ]) - } - - private removeLink(index: number) { - this.updateField( - 'links', - this.form.links.filter((_, itemIndex) => itemIndex !== index), - ) + private handleInput() { + this.localDirty = true + this.profileRepo.setAdminDirty(true) } - private splitLines(value: string) { - return value - .split('\n') - .map((item) => item.trim()) - .filter(Boolean) - } + private async handleSubmit(event: Event) { + event.preventDefault() + const form = event.target as HTMLFormElement + if (!form.checkValidity()) { + form.reportValidity() + return + } - private toOptionalNumber(value: string) { - const trimmed = value.trim() - return trimmed === '' ? undefined : Number(trimmed) - } + const formData = new FormData(form) + + const profile: MeProfile = { + displayName: formData.get('displayName') as string, + displayJa: formData.get('displayJa') as string, + role: formData.get('role') as string, + location: formData.get('location') as string, + skills: JSON.parse((formData.get('skills') as string) || '[]'), + certifications: JSON.parse( + (formData.get('certifications') as string) || '[]', + ), + experiences: JSON.parse((formData.get('experiences') as string) || '[]'), + links: JSON.parse((formData.get('links') as string) || '[]'), + likes: ((formData.get('likes') as string) || '') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean), + updatedAt: this.profileRepo.adminProfile.updatedAt, + } - private handleSubmit(event: Event) { - event.preventDefault() - this.dispatchEvent( - new CustomEvent('admin-save-profile', { - detail: cloneMeProfile(this.form), - bubbles: true, - composed: true, - }), - ) + await this.profileRepo.saveAdminProfile(profile) + this.localDirty = false } - private handleReset = () => { + private handleReset = (e: Event) => { + e.preventDefault() if ( - this.isDirty && + (this.profileRepo.adminDirty || this.localDirty) && !window.confirm('未保存の変更を破棄して元に戻しますか?') ) { return } - - this.setForm(cloneMeProfile(this.profile)) - } - - private setForm(nextForm: MeProfile) { - this.form = nextForm - this.updateDirtyState(nextForm) - } - - private updateDirtyState(nextForm: MeProfile) { - const nextDirty = !this.profilesEqual(nextForm, this.profile) - if (nextDirty === this.isDirty) return - - this.isDirty = nextDirty - this.dispatchEvent( - new CustomEvent('admin-profile-dirty-change', { - detail: nextDirty, - bubbles: true, - composed: true, - }), - ) - } - - private profilesEqual(a: MeProfile, b: MeProfile) { - return JSON.stringify(a) === JSON.stringify(b) - } - - private renderEmptyPanel( - message: string, - actionLabel: string, - onClick: () => void, - ) { - return html`
-

${message}

- -
` + this.localDirty = false + this.profileRepo.setAdminDirty(false) + this.requestUpdate() // Force re-render to reset inputs to repo values } static styles = [ @@ -736,86 +253,9 @@ export class PageAdminProfile extends LitElement { line-height: 1.8; } - .meta { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 16px; - } - - .meta span { - display: inline-flex; - align-items: center; - height: 28px; - padding: 0 10px; - border: 1px solid var(--color-border); - background: var(--color-bg-surface); - color: var(--color-text-secondary); - font-size: 12px; - } - - form, - .stack { - display: grid; - gap: 20px; - } - - .section-copy { + form { display: grid; - gap: 6px; - } - - .section-help { - color: var(--color-text-tertiary); - font-size: 13px; - line-height: 1.8; - } - - .section { - display: grid; - gap: 16px; - padding: 24px; - border: 1px solid var(--color-border); - background: #fff; - } - - .section-header, - .panel-header { - display: flex; - justify-content: space-between; - gap: 16px; - align-items: center; - flex-wrap: wrap; - } - - .panel { - display: grid; - gap: 16px; - border: 1px solid var(--color-border-subtle); - background: var(--color-bg-surface); - padding: 20px; - } - - .empty-panel { - display: grid; - justify-items: start; - gap: 12px; - border: 1px dashed var(--color-border); - padding: 18px; - color: var(--color-text-secondary); - font-size: 14px; - } - - h2 { - font-size: 16px; - font-weight: 500; - color: var(--color-text-primary); - } - - h3 { - font-size: 14px; - font-weight: 500; - color: var(--color-text-secondary); + gap: 24px; } .grid { @@ -835,6 +275,7 @@ export class PageAdminProfile extends LitElement { border: 1px solid var(--color-border); background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(12px); + z-index: 10; } .actions-copy { diff --git a/frontend/src/pages/page-top.ts b/frontend/src/pages/page-top.ts index a0b6c7c..c65a5a0 100644 --- a/frontend/src/pages/page-top.ts +++ b/frontend/src/pages/page-top.ts @@ -1,15 +1,28 @@ +import { consume } from '@lit/context' import { css, html, LitElement, nothing } from 'lit' -import { customElement, property, state } from 'lit/decorators.js' +import { customElement, state } from 'lit/decorators.js' import { listArticles } from '../admin/article-api.js' import type { ArticleItem } from '../admin/article-types.js' -import type { MeProfile } from '../admin/types.js' +import { profileContext } from '../contexts/profile-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import type { IProfileRepository } from '../domain/ProfileRepository.js' import { setupAmbientLines } from '../utils/ambient.js' import { setupFade, setupReveal } from '../utils/scroll.js' @customElement('page-top') export class PageTop extends LitElement { - @property({ attribute: false }) profile: MeProfile | null = null - @property({ type: Boolean }) loading = false + @consume({ context: profileContext, subscribe: true }) + set profileRepo(repo: IProfileRepository) { + if (this._profileRepo === repo) return + this._profileRepo = repo + if (this._observer) this._observer.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get profileRepo() { + return this._profileRepo + } + private _profileRepo!: IProfileRepository + private _observer?: RepositoryObserver @state() private articles: ArticleItem[] = [] @@ -44,19 +57,22 @@ export class PageTop extends LitElement { } render() { + const p = this.profileRepo.publicProfile + const loading = this.profileRepo.publicLoading + return html`
-

- ${this.profile?.displayName ?? ''} +

+ ${p?.displayName ?? ''}

-

${this.profile?.role ?? ''}

-

${this.profile?.location ?? ''}

+

${p?.role ?? ''}

+

${p?.location ?? ''}

@@ -82,16 +98,17 @@ export class PageTop extends LitElement {

Say Hello

@@ -100,6 +117,14 @@ export class PageTop extends LitElement { ` } + private sanitizeUrl(url: string): string { + const trimmed = url.trim() + if (/^(https?|mailto):/i.test(trimmed)) { + return trimmed + } + return '#' + } + private async loadArticles() { this.articlesLoading = true this.articlesError = '' @@ -189,18 +214,18 @@ export class PageTop extends LitElement { color: var(--color-text-primary); margin: 0; animation: breathing 8s ease-in-out infinite; - text-shadow: 0 0 20px rgba(209, 205, 199, 0); + text-shadow: 0 0 20px rgba(240, 237, 231, 0); transition: text-shadow 0.5s ease; } @keyframes breathing { 0%, 100% { opacity: 0.7; - text-shadow: 0 0 30px rgba(209, 205, 199, 0); + text-shadow: 0 0 30px rgba(240, 237, 231, 0); } 50% { opacity: 1; - text-shadow: 0 0 40px rgba(209, 205, 199, 0.15); + text-shadow: 0 0 40px rgba(240, 237, 231, 0.15); } } diff --git a/frontend/src/styles/theme-admin.css b/frontend/src/styles/theme-admin.css new file mode 100644 index 0000000..5ad1b38 --- /dev/null +++ b/frontend/src/styles/theme-admin.css @@ -0,0 +1,45 @@ +:root[data-theme="admin"] { + /* Semantic Colors - Admin Light Workspace */ + --color-bg-deep: #f5f5f5; + --color-bg-dim: #ffffff; + --color-bg-surface: #ffffff; + + --color-text-primary: #1a1a1a; + --color-text-secondary: #4a4a4a; + --color-text-tertiary: #8a8a8a; + --color-text-mute: #bababa; + + --color-border: #d9d9d9; + --color-border-subtle: #e8e8e8; + + /* Semantic Typography */ + --font-en: var(--font-sans); + --font-jp: var(--font-sans); + --tracking-wide: 0.02em; + --tracking-wider: 0.04em; + + /* Admin specific functional tokens */ + --admin-accent: #0057b8; + --admin-accent-hover: #004494; + --admin-sidebar-width: 220px; + + /* Semantic status colors */ + --color-danger: #c0392b; + --color-danger-bg: rgba(192, 57, 43, 0.06); + --color-success: #3d7a56; + --color-success-bg: rgba(61, 122, 86, 0.06); + --color-notice: #5a6b85; + --color-notice-bg: rgba(90, 107, 133, 0.08); +} + +/* Theme specific global styles */ +html[data-theme="admin"] body { + background-color: var(--color-bg-deep); + color: var(--color-text-primary); + font-family: var(--font-jp); + font-weight: 400; +} + +html[data-theme="admin"] body::before { + display: none !important; +} diff --git a/frontend/src/styles/theme-public.css b/frontend/src/styles/theme-public.css new file mode 100644 index 0000000..9a536b9 --- /dev/null +++ b/frontend/src/styles/theme-public.css @@ -0,0 +1,43 @@ +:root[data-theme="public"] { + /* Semantic Colors - Chiaroscuro */ + --color-bg-deep: #0d0d0c; + --color-bg-dim: #161514; + --color-bg-surface: #1a1917; + + --color-text-primary: #f0ede7; + --color-text-secondary: #aaa6a0; + --color-text-tertiary: #7a7670; + --color-text-mute: #4a4844; + + --color-border: #2c2a26; + --color-border-subtle: #1f1e1c; + --color-glow: rgba(240, 237, 231, 0.2); + --color-glow-sharp: rgba(240, 237, 231, 0.4); + + /* Semantic Typography */ + --font-en: var(--font-serif-en); + --font-jp: var(--font-serif-jp); + --tracking-wide: 0.08em; + --tracking-wider: 0.15em; +} + +/* Theme specific global styles */ +html[data-theme="public"] body { + background-color: var(--color-bg-deep); + color: var(--color-text-primary); + font-family: var(--font-jp); + font-weight: 300; +} + +html[data-theme="public"] body::before { + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 9999; + opacity: 0.04; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..295e4a4 --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,22 @@ +:root { + /* Spacing Primitives */ + --space-xs: 8px; + --space-sm: 16px; + --space-md: 32px; + --space-lg: 64px; + --space-xl: 120px; + + /* Typography Primitives */ + --font-serif-en: "Cormorant Garamond", serif; + --font-serif-jp: "Noto Serif JP", serif; + --font-sans: system-ui, -apple-system, sans-serif; + + /* Letter spacing Primitives */ + --tracking-tight: 0.02em; + --tracking-normal: 0.04em; + --tracking-wide: 0.08em; + --tracking-wider: 0.15em; + + /* Motion Primitives */ + --easing-smooth: cubic-bezier(0.22, 1, 0.36, 1); +} diff --git a/frontend/src/tokens.css b/frontend/src/tokens.css deleted file mode 100644 index 0b06ee9..0000000 --- a/frontend/src/tokens.css +++ /dev/null @@ -1,37 +0,0 @@ -:root { - /* Colors - Chiaroscuro Final Edition */ - /* Backgrounds */ - --color-bg-deep: #0d0d0c; /* 最も深い夜 */ - --color-bg-dim: #161514; /* わずかに明るい夜(レイヤー用) */ - --color-bg-surface: #1a1917; /* ホバーやカード用 */ - - /* Text & Light */ - --color-text-primary: #f0ede7; /* 月明かりの核(高コントラスト) */ - --color-text-secondary: #aaa6a0; /* 柔らかな光 */ - --color-text-tertiary: #7a7670; /* 影に溶ける文字(ハッシュ、件数) */ - --color-text-mute: #4a4844; /* ほぼ闇に近い */ - - /* Borders & Accents */ - --color-border: #2c2a26; - --color-border-subtle: #1f1e1c; - --color-glow: rgba(240, 237, 231, 0.2); - --color-glow-sharp: rgba(240, 237, 231, 0.4); - - /* Typography */ - --font-en: "Cormorant Garamond", serif; - --font-jp: "Noto Serif JP", serif; - - /* Spacing */ - --space-xs: 8px; - --space-sm: 16px; - --space-md: 32px; - --space-lg: 64px; - --space-xl: 120px; - - /* Letter spacing */ - --tracking-wide: 0.08em; - --tracking-wider: 0.15em; - - /* Easing */ - --easing-smooth: cubic-bezier(0.22, 1, 0.36, 1); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b001f17..5c9670c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: '@lit-labs/router': specifier: ^0.1.4 version: 0.1.4 + '@lit/context': + specifier: ^1.1.6 + version: 1.1.6 lit: specifier: ^3.3.2 version: 3.3.2 @@ -104,6 +107,9 @@ packages: '@lit-labs/ssr-dom-shim@1.5.1': resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} + '@lit/context@1.1.6': + resolution: {integrity: sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==} + '@lit/reactive-element@2.1.2': resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} @@ -463,6 +469,10 @@ snapshots: '@lit-labs/ssr-dom-shim@1.5.1': {} + '@lit/context@1.1.6': + dependencies: + '@lit/reactive-element': 2.1.2 + '@lit/reactive-element@2.1.2': dependencies: '@lit-labs/ssr-dom-shim': 1.5.1