Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/FUNDING.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: ['paypal.me/antongithub']
custom: ['https://paypal.me/antongithub']
2 changes: 1 addition & 1 deletion src/app/styles/variables.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
--header-height: 50px;
--editor-component-list-width: 250px;
--editor-tools-width: 385px;
--sidebar-footer-height: 135px;
--sidebar-footer-height: 240px;
}
73 changes: 73 additions & 0 deletions src/features/editor/components/header/ExportSupportDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { Heart, Star } from 'lucide-vue-next'
import { useExportSupportDialog } from '@/features/editor/model'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'

const { exportSupportDialogVisible, closeExportSupportDialog } = useExportSupportDialog()

function onOpenChange(open: boolean) {
if (!open && exportSupportDialogVisible.value)
closeExportSupportDialog()
}
</script>

<template>
<Dialog
:open="exportSupportDialogVisible"
@update:open="onOpenChange"
>
<DialogContent class="sm:max-w-[440px]">
<div
class="mx-auto flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<Star class="size-6 fill-current" />
</div>

<DialogHeader class="text-center sm:text-center">
<DialogTitle>Your email is ready 🎉</DialogTitle>
<DialogDescription class="leading-6">
Card is free and open source. If it saves you time, support the project with a GitHub star
or a donation.
</DialogDescription>
</DialogHeader>

<div class="grid gap-2">
<Button
as="a"
href="https://github.com/mysigmail/card"
target="_blank"
rel="noopener noreferrer"
@click="closeExportSupportDialog"
>
<Star class="fill-current" />
Star Card on GitHub
</Button>
<Button
as="a"
variant="outline"
href="https://opencollective.com/mysigmail"
target="_blank"
rel="noopener noreferrer"
@click="closeExportSupportDialog"
>
<Heart />
Support development
</Button>
<Button
variant="ghost"
class="text-muted-foreground"
@click="closeExportSupportDialog"
>
Not now
</Button>
</div>
</DialogContent>
</Dialog>
</template>
3 changes: 3 additions & 0 deletions src/features/editor/components/header/TheHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { Separator } from '@/shared/ui/separator'
import { Switch } from '@/shared/ui/switch'
import { ToggleGroup, ToggleGroupItem } from '@/shared/ui/toggle-group'
import ExportSupportDialog from './ExportSupportDialog.vue'
import { useHeaderActions } from './use-header-actions'

const {
Expand Down Expand Up @@ -287,4 +288,6 @@ const {
</DialogFooter>
</DialogContent>
</Dialog>

<ExportSupportDialog />
</template>
11 changes: 10 additions & 1 deletion src/features/editor/components/header/use-header-actions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import type { TemplateImportMode } from '@/entities/template'
import { useEventListener } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useCanvas, useColorPalettes, useHistory, useTemplateIO } from '@/features/editor/model'
import {
useCanvas,
useColorPalettes,
useExportSupportDialog,
useHistory,
useTemplateIO,
} from '@/features/editor/model'

function isTextInputElement(target: EventTarget | null) {
if (!(target instanceof HTMLElement))
Expand All @@ -17,6 +23,7 @@ export function useHeaderActions() {
const { exportTemplateHtml, exportTemplateJson, importTemplateFromJson } = useTemplateIO()
const { templateImportIssues, clearCanvas, previewMode } = useCanvas()
const { clearRecentColors } = useColorPalettes()
const { recordExportAndMaybeShowDialog } = useExportSupportDialog()
const { canUndo, canRedo, undo, redo, resetHistory } = useHistory()

const importDialogVisible = ref(false)
Expand Down Expand Up @@ -73,6 +80,7 @@ export function useHeaderActions() {

importStatus.value = 'idle'
importMessage.value = ''
recordExportAndMaybeShowDialog()
}

function exportTemplateHtmlToFile() {
Expand All @@ -91,6 +99,7 @@ export function useHeaderActions() {

importStatus.value = 'idle'
importMessage.value = ''
recordExportAndMaybeShowDialog()
}

function resetImportDialog() {
Expand Down
23 changes: 4 additions & 19 deletions src/features/editor/components/sidebar/SidebarFooter.vue
Original file line number Diff line number Diff line change
@@ -1,28 +1,13 @@
<script setup lang="ts">
import { version } from '#/package.json'
import { Star } from 'lucide-vue-next'

const year = new Date().getFullYear()
</script>

<template>
<div class="p-4 text-xs text-muted-foreground">
<div class="leading-[18px]">
<div class="mb-2 rounded-md border border-input py-2 px-3">
If you like this project,<br>please give it a
<span class="">
<Star class="size-4 fill-yellow-500 text-yellow-500 inline-flex relative -top-[1px]" />
on
<a
class="text-muted-foreground underline-offset-4 underline hover:text-primary"
href="https://github.com/mysigmail/card"
target="_blank"
rel="noopener noreferrer"
>GitHub</a>!
</span>
</div>
</div>
<div class="leading-[18px]">
<div class="p-3 text-xs text-muted-foreground">
<SidebarSupportCard class="mb-2" />
<div class="leading-4">
v{{ version }}&nbsp;•&nbsp;
<a
class="text-muted-foreground"
Expand All @@ -31,7 +16,7 @@ const year = new Date().getFullYear()
rel="noopener noreferrer"
>Changelog</a>
</div>
<div class="leading-[18px]">
<div class="leading-4">
©{{ year }}&nbsp;•&nbsp;Anton Reshetov
</div>
</div>
Expand Down
81 changes: 81 additions & 0 deletions src/features/editor/components/sidebar/SidebarSupportCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { Heart, Star } from 'lucide-vue-next'
import { Button } from '@/shared/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/shared/ui/dropdown-menu'
</script>

<template>
<div
data-slot="sidebar-support-card"
class="rounded-lg border border-border bg-muted/30 p-3"
>
<div class="mb-1 font-medium leading-4 text-foreground">
Keep email design open
</div>
<p class="mb-3 leading-4">
Card is the production-grade, open-source alternative to commercial email builders.
</p>

<Button
as="a"
size="sm"
class="mb-1 w-full"
href="https://github.com/mysigmail/card"
target="_blank"
rel="noopener noreferrer"
>
<Star class="fill-current" />
Star Card on GitHub
</Button>

<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
class="w-full text-muted-foreground hover:text-foreground"
>
<Heart />
Support development
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="center"
class="w-56"
>
<DropdownMenuLabel>Support Card development</DropdownMenuLabel>
<DropdownMenuItem as-child>
<a
href="https://opencollective.com/mysigmail"
target="_blank"
rel="noopener noreferrer"
>
<span class="flex flex-col">
<span>Open Collective</span>
<span class="text-xs text-muted-foreground">Transparent funding</span>
</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<a
href="https://paypal.me/antongithub"
target="_blank"
rel="noopener noreferrer"
>
<span class="flex flex-col">
<span>PayPal</span>
<span class="text-xs text-muted-foreground">One-time donation</span>
</span>
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
1 change: 1 addition & 0 deletions src/features/editor/model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
export * from './types'
export { useCanvas } from './use-canvas'
export { useColorPalettes } from './use-color-palettes'
export { useExportSupportDialog } from './use-export-support-dialog'
export { useHistory } from './use-history'
export { usePersistence } from './use-persistence'
export { useSelection } from './use-selection'
Expand Down
85 changes: 85 additions & 0 deletions src/features/editor/model/use-export-support-dialog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { readonly, ref } from 'vue'

const EXPORT_SUPPORT_DIALOG_STORAGE_KEY = 'card.export-support-dialog.v1'
const EXPORT_SUPPORT_DIALOG_FIRST_PROMPT_AT = 1
const EXPORT_SUPPORT_DIALOG_REPEAT_INTERVAL = 5

const visible = ref(false)

interface ExportSupportDialogState {
exportCount: number
nextPromptAt: number
}

const defaultState: ExportSupportDialogState = {
exportCount: 0,
nextPromptAt: EXPORT_SUPPORT_DIALOG_FIRST_PROMPT_AT,
}

function readState(): ExportSupportDialogState {
try {
const raw = window.localStorage.getItem(EXPORT_SUPPORT_DIALOG_STORAGE_KEY)
if (!raw)
return { ...defaultState }

const state = JSON.parse(raw) as Partial<ExportSupportDialogState>
if (
Number.isInteger(state.exportCount)
&& Number.isInteger(state.nextPromptAt)
&& state.exportCount! >= 0
&& state.nextPromptAt! > 0
) {
return {
exportCount: state.exportCount!,
nextPromptAt: state.nextPromptAt!,
}
}

return { ...defaultState }
}
catch {
return { ...defaultState }
}
}

function writeState(state: ExportSupportDialogState) {
try {
window.localStorage.setItem(EXPORT_SUPPORT_DIALOG_STORAGE_KEY, JSON.stringify(state))
}
catch {
// The choice still applies for the current session when storage is unavailable.
}
}

function resolveFollowingPromptAt(currentPromptAt: number) {
if (currentPromptAt === EXPORT_SUPPORT_DIALOG_FIRST_PROMPT_AT)
return EXPORT_SUPPORT_DIALOG_REPEAT_INTERVAL

return currentPromptAt + EXPORT_SUPPORT_DIALOG_REPEAT_INTERVAL
}

function recordExportAndMaybeShowDialog() {
const state = readState()
const exportCount = state.exportCount + 1
const shouldShow = exportCount >= state.nextPromptAt

writeState({
exportCount,
nextPromptAt: shouldShow ? resolveFollowingPromptAt(state.nextPromptAt) : state.nextPromptAt,
})

if (shouldShow)
visible.value = true
}

function closeExportSupportDialog() {
visible.value = false
}

export function useExportSupportDialog() {
return {
exportSupportDialogVisible: readonly(visible),
recordExportAndMaybeShowDialog,
closeExportSupportDialog,
}
}
Loading