feat(plan): dönem notları — Markdown, yaz/önizle sekmeleri
- NotesSection: ekle/düzenle/sil (onaylı), yazar ve tarih, kapalı dönemde salt okunur - Markdown gösterimi (react-markdown + remark-gfm): ham HTML yok, güvenli bağlantılar - Çakışma (409) mesajı formda; Plan sayfasına bağlandı - Dağıtım çubuğu boş dönemde "tamamı dağıtıldı" demiyor - Faz 4 tamamlandı Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1473
-3
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,9 @@
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.92.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.5.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.23.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwindcss": "^4.3.5",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Not metnini Markdown olarak gösterir (GFM: tablo, ~~üstü çizili~~, `- [ ]` yapılacaklar).
|
||||
* Güvenlik: ham HTML işlenmez (react-markdown varsayılanı); `javascript:` gibi
|
||||
* tehlikeli bağlantılar kütüphane tarafından temizlenir. Bağlantılar yeni sekmede açılır.
|
||||
*/
|
||||
const components: Components = {
|
||||
a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
||||
}
|
||||
|
||||
export function Markdown({ children, className }: { children: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn('markdown', className)}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
// shadcn textarea (Input ile aynı görünüm); Kakeibo: mobilde text-base → iOS yakınlaştırmaz
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-40 w-full rounded-lg border border-input bg-transparent px-3 py-2.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { FormError, FormField } from '@/components/form-field'
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { noteSchema, type NoteForm } from './schema'
|
||||
|
||||
const s = t.notes
|
||||
|
||||
/**
|
||||
* Not formu: başlık + Markdown metin, "Yaz / Önizle" sekmeleri.
|
||||
* `onSave` hata fırlatırsa mesaj formda gösterilir (ör. 409: başkası değiştirmiş).
|
||||
*/
|
||||
export function NoteEditor({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: NoteForm
|
||||
onSave: (v: NoteForm) => Promise<void>
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<'write' | 'preview'>('write')
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<NoteForm>({
|
||||
resolver: zodResolver(noteSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: initial ?? { title: '', body_md: '' },
|
||||
})
|
||||
const body = useWatch({ control, name: 'body_md' })
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
try {
|
||||
await onSave(v)
|
||||
} catch (e) {
|
||||
setError('root', { message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
})
|
||||
|
||||
const tabButton = (value: typeof tab, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`note-tab-${value}`}
|
||||
aria-selected={tab === value}
|
||||
aria-controls={`note-panel-${value}`}
|
||||
onClick={() => setTab(value)}
|
||||
className={cn(
|
||||
'h-11 rounded-md px-4 text-sm font-medium transition-colors',
|
||||
tab === value ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4 rounded-xl border bg-card p-4">
|
||||
<FormField label={s.noteTitle} error={errors.title}>
|
||||
{(f) => (
|
||||
<Input
|
||||
{...f}
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
placeholder={s.titlePlaceholder}
|
||||
{...register('title')}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div role="tablist" aria-label={s.body} className="inline-flex rounded-lg bg-muted p-1">
|
||||
{tabButton('write', s.write)}
|
||||
{tabButton('preview', s.preview)}
|
||||
</div>
|
||||
{tab === 'write' ? (
|
||||
<div id="note-panel-write" role="tabpanel" aria-labelledby="note-tab-write">
|
||||
<FormField label={s.body} error={errors.body_md} hint={s.markdownHint}>
|
||||
{(f) => <Textarea {...f} placeholder={s.bodyPlaceholder} {...register('body_md')} />}
|
||||
</FormField>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
id="note-panel-preview"
|
||||
role="tabpanel"
|
||||
aria-labelledby="note-tab-preview"
|
||||
className="min-h-40 rounded-lg border p-3"
|
||||
>
|
||||
{body.trim() ? (
|
||||
<Markdown>{body}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{s.previewEmpty}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormError message={errors.root?.message} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
{s.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{isSubmitting ? s.saving : s.save}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { PageError, PageLoading } from '@/components/page'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { useCreateNote, useDeleteNote, useHousehold, useNotes, useUpdateNote } from '@/lib/notes'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { Note } from '@/types/Note'
|
||||
import type { NoteContext } from '@/types/NoteContext'
|
||||
import { NoteEditor } from './note-editor'
|
||||
|
||||
const s = t.notes
|
||||
|
||||
/**
|
||||
* Döneme özel notlar (Plan ve ileride Değerlendirme sayfası).
|
||||
* `readOnly`: kapanmış dönemde ekleme/düzenleme yok.
|
||||
*/
|
||||
export function NotesSection({
|
||||
periodId,
|
||||
context,
|
||||
readOnly = false,
|
||||
}: {
|
||||
periodId: string
|
||||
context: NoteContext
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const notes = useNotes(periodId, context)
|
||||
const household = useHousehold()
|
||||
const create = useCreateNote(periodId, context)
|
||||
const update = useUpdateNote(periodId, context)
|
||||
const remove = useDeleteNote(periodId, context)
|
||||
/** `'new'` → yeni not formu; not kimliği → o not düzenleniyor */
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
const author = (id: string) => household.data?.members.find((m) => m.id === id)?.name
|
||||
|
||||
const onRemove = (note: Note) => {
|
||||
if (!window.confirm(s.confirmRemove(note.title))) return
|
||||
remove.mutate(note.id, {
|
||||
onSuccess: () => toast.success(s.removed),
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="notes-title" className="rounded-2xl border bg-card p-4 sm:p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 id="notes-title" className="font-semibold">
|
||||
🗒️ {s.title}
|
||||
</h3>
|
||||
{!readOnly && editing !== 'new' && (
|
||||
<Button variant="secondary" onClick={() => setEditing('new')}>
|
||||
<Plus aria-hidden />
|
||||
{s.add}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{editing === 'new' && (
|
||||
<NoteEditor
|
||||
onCancel={() => setEditing(null)}
|
||||
onSave={async (v) => {
|
||||
await create.mutateAsync(v)
|
||||
toast.success(s.saved)
|
||||
setEditing(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notes.isPending ? (
|
||||
<PageLoading />
|
||||
) : notes.isError ? (
|
||||
<PageError message={notes.error.message} onRetry={notes.refetch} />
|
||||
) : notes.data.length === 0 && editing !== 'new' ? (
|
||||
<p className="text-sm text-muted-foreground">{s.empty}</p>
|
||||
) : (
|
||||
notes.data.map((note) =>
|
||||
editing === note.id ? (
|
||||
<NoteEditor
|
||||
key={note.id}
|
||||
initial={{ title: note.title, body_md: note.body_md }}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSave={async (v) => {
|
||||
await update.mutateAsync({ id: note.id, version: note.version, ...v })
|
||||
toast.success(s.saved)
|
||||
setEditing(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<article key={note.id} className="rounded-xl border p-4">
|
||||
<header className="mb-2 flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-medium break-words">{note.title}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.meta(formatDate(note.updated_at), author(note.created_by))}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="-mt-2 -mr-2 flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={s.editNote(note.title)}
|
||||
onClick={() => setEditing(note.id)}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={s.removeNote(note.title)}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => onRemove(note)}
|
||||
>
|
||||
<Trash2 className="text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{note.body_md.trim() && <Markdown>{note.body_md}</Markdown>}
|
||||
</article>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { t } from '@/lib/strings'
|
||||
import { noteSchema } from './schema'
|
||||
|
||||
const err = (title: string, body_md = '') => {
|
||||
const r = noteSchema.safeParse({ title, body_md })
|
||||
return r.success ? null : r.error.issues[0].message
|
||||
}
|
||||
|
||||
describe('noteSchema', () => {
|
||||
it('accepts a note with an empty body', () => {
|
||||
expect(err('Hedefler')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects empty and too long titles', () => {
|
||||
expect(err(' ')).toBe(t.notes.titleRequired)
|
||||
expect(err('x'.repeat(121))).toBe(t.notes.titleTooLong)
|
||||
})
|
||||
|
||||
it('counts characters like the backend (emoji = 1)', () => {
|
||||
expect(err('🎯'.repeat(120))).toBeNull() // UTF-16'da 240 birim
|
||||
expect(err('Not', '🎯'.repeat(20_000))).toBeNull()
|
||||
expect(err('Not', 'a'.repeat(20_001))).toBe(t.notes.bodyTooLong)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
const s = t.notes
|
||||
|
||||
/** Karakter sayısı backend gibi Unicode karakteri sayar (emoji = 1), `length` gibi UTF-16 değil */
|
||||
const chars = (v: string) => [...v].length
|
||||
|
||||
export const noteSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, s.titleRequired)
|
||||
.refine((v) => chars(v) <= 120, s.titleTooLong),
|
||||
body_md: z.string().refine((v) => chars(v) <= 20_000, s.bodyTooLong),
|
||||
})
|
||||
export type NoteForm = z.infer<typeof noteSchema>
|
||||
@@ -28,18 +28,21 @@ export function DistributionBar({
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm',
|
||||
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{remaining < 0
|
||||
? s.over(formatMoney(-remaining))
|
||||
: remaining > 0
|
||||
? s.unallocated(formatMoney(remaining))
|
||||
: s.balanced}
|
||||
</p>
|
||||
{/* Hiç dağıtılacak ve dağıtılmış tutar yoksa "tamamı dağıtıldı" yanıltıcı olur */}
|
||||
{(distributed !== 0 || spendable !== 0) && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm',
|
||||
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{remaining < 0
|
||||
? s.over(formatMoney(-remaining))
|
||||
: remaining > 0
|
||||
? s.unallocated(formatMoney(remaining))
|
||||
: s.balanced}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { NotesSection } from '@/features/notes/notes-section'
|
||||
import { CopyPrevious } from '@/features/period-wizard/step-items'
|
||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||
import { useBudget, useCurrentPeriod, usePlanItems, useSummary } from '@/lib/periods'
|
||||
@@ -76,6 +77,7 @@ function PlanView({ period }: { period: Period }) {
|
||||
</div>
|
||||
{editable && <CopyPrevious period={period} />}
|
||||
<BudgetRead budget={budget.data} summary={summary.data} />
|
||||
<NotesSection periodId={period.id} context="planning" readOnly={!editable} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -207,3 +207,61 @@
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Not metni (components/markdown.tsx) — typography eklentisi yerine küçük, elle stil */
|
||||
@layer components {
|
||||
.markdown {
|
||||
@apply space-y-3 text-sm leading-relaxed break-words;
|
||||
}
|
||||
.markdown h1 {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
.markdown h2 {
|
||||
@apply text-base font-semibold;
|
||||
}
|
||||
.markdown h3,
|
||||
.markdown h4 {
|
||||
@apply font-semibold;
|
||||
}
|
||||
.markdown ul {
|
||||
@apply list-disc space-y-1 pl-5;
|
||||
}
|
||||
.markdown ol {
|
||||
@apply list-decimal space-y-1 pl-5;
|
||||
}
|
||||
/* - [ ] yapılacaklar: madde işareti yerine kutucuk */
|
||||
.markdown ul:has(> li > input[type='checkbox']) {
|
||||
@apply list-none pl-1;
|
||||
}
|
||||
.markdown li > input[type='checkbox'] {
|
||||
@apply mr-2 size-4 translate-y-0.5 accent-primary;
|
||||
}
|
||||
.markdown a {
|
||||
@apply font-medium text-primary underline underline-offset-4;
|
||||
}
|
||||
.markdown blockquote {
|
||||
@apply border-l-4 pl-3 text-muted-foreground;
|
||||
}
|
||||
.markdown code {
|
||||
@apply rounded bg-muted px-1 py-0.5 font-mono text-[0.85em];
|
||||
}
|
||||
.markdown pre {
|
||||
@apply overflow-x-auto rounded-lg bg-muted p-3;
|
||||
}
|
||||
.markdown pre code {
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
.markdown table {
|
||||
@apply block w-full overflow-x-auto border-collapse;
|
||||
}
|
||||
.markdown th,
|
||||
.markdown td {
|
||||
@apply border px-2 py-1 text-left;
|
||||
}
|
||||
.markdown th {
|
||||
@apply bg-muted font-medium;
|
||||
}
|
||||
.markdown hr {
|
||||
@apply border-border;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Dönem notları (planlama / değerlendirme sayfası) ve yazar adı için hane üyeleri.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Household } from '@/types/Household'
|
||||
import type { Note } from '@/types/Note'
|
||||
import type { NoteContext } from '@/types/NoteContext'
|
||||
import type { NoteCreateRequest } from '@/types/NoteCreateRequest'
|
||||
import type { NoteUpdateRequest } from '@/types/NoteUpdateRequest'
|
||||
import { api } from './api'
|
||||
import { queryKeys } from './query'
|
||||
|
||||
export function useNotes(periodId: string, context: NoteContext) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.notes(periodId, context),
|
||||
queryFn: () => api.get<Note[]>(`/periods/${periodId}/notes?context=${context}`),
|
||||
})
|
||||
}
|
||||
|
||||
function useRefreshNotes(periodId: string, context: NoteContext) {
|
||||
const qc = useQueryClient()
|
||||
return () => qc.invalidateQueries({ queryKey: queryKeys.notes(periodId, context) })
|
||||
}
|
||||
|
||||
export function useCreateNote(periodId: string, context: NoteContext) {
|
||||
const refresh = useRefreshNotes(periodId, context)
|
||||
return useMutation({
|
||||
mutationFn: (body: Omit<NoteCreateRequest, 'context'>) =>
|
||||
api.post<Note>(`/periods/${periodId}/notes`, { ...body, context }),
|
||||
onSuccess: refresh,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateNote(periodId: string, context: NoteContext) {
|
||||
const refresh = useRefreshNotes(periodId, context)
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: NoteUpdateRequest & { id: string }) =>
|
||||
api.patch<Note>(`/notes/${id}`, body),
|
||||
// 409 (başkası değiştirmiş) dahil her durumda güncel listeyi getir
|
||||
onSettled: refresh,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteNote(periodId: string, context: NoteContext) {
|
||||
const refresh = useRefreshNotes(periodId, context)
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/notes/${id}`),
|
||||
onSettled: refresh,
|
||||
})
|
||||
}
|
||||
|
||||
/** Hane bilgisi + üyeler (notun yazarını göstermek için) */
|
||||
export function useHousehold() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.household,
|
||||
queryFn: () => api.get<Household>('/household'),
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const queryKeys = {
|
||||
summary: (id: string) => ['period', id, 'summary'] as const,
|
||||
budget: (id: string) => ['period', id, 'budget'] as const,
|
||||
suggestion: (id: string, method: string) => ['period', id, 'suggestion', method] as const,
|
||||
notes: (id: string, context: string) => ['period', id, 'notes', context] as const,
|
||||
}
|
||||
|
||||
/** 4xx hataları tekrar denemenin anlamı yok (yanlış istek / yetki); ağ ve 5xx için 2 kez dene. */
|
||||
|
||||
@@ -212,6 +212,34 @@ export const t = {
|
||||
emptyBudget: 'Henüz bütçe dağıtılmadı. "Düzenle" ile 4 gruba dağıtabilirsin.',
|
||||
},
|
||||
|
||||
notes: {
|
||||
title: 'Notlar',
|
||||
empty: 'Henüz not yok. Bu dönemle ilgili düşüncelerini, kararlarını ya da yapılacakları yaz.',
|
||||
add: 'Not ekle',
|
||||
edit: 'Düzenle',
|
||||
remove: 'Sil',
|
||||
editNote: (title: string) => `${title} notunu düzenle`,
|
||||
removeNote: (title: string) => `${title} notunu sil`,
|
||||
confirmRemove: (title: string) => `"${title}" notu silinsin mi?`,
|
||||
removed: 'Not silindi',
|
||||
saved: 'Not kaydedildi',
|
||||
noteTitle: 'Başlık',
|
||||
titlePlaceholder: 'ör. Bu ayın hedefleri',
|
||||
body: 'Metin',
|
||||
bodyPlaceholder: '- [ ] Elektrik faturasını öde\n- Dışarıda yemek ayda en fazla 2 kez',
|
||||
write: 'Yaz',
|
||||
preview: 'Önizle',
|
||||
previewEmpty: 'Önizlenecek bir şey yok.',
|
||||
markdownHint: 'Markdown: **kalın**, *italik*, - liste, - [ ] yapılacak, # başlık',
|
||||
save: 'Kaydet',
|
||||
cancel: 'Vazgeç',
|
||||
saving: 'Kaydediliyor…',
|
||||
meta: (date: string, author?: string) => (author ? `${date} · ${author}` : date),
|
||||
titleRequired: 'Başlık yazın',
|
||||
titleTooLong: 'Başlık en fazla 120 karakter olabilir',
|
||||
bodyTooLong: 'Metin en fazla 20.000 karakter olabilir',
|
||||
},
|
||||
|
||||
summary: {
|
||||
income: 'Gelir',
|
||||
fixed: 'Sabit giderler',
|
||||
|
||||
Reference in New Issue
Block a user