Google Fonts (#525)
* Google Fonts * Google fonts improvement * font button color * Refine font selection UI and update font fetching logic - Update FontsController to fetch Google fonts sorted by popularity. - Enhance FontCard component with additional skeleton loaders for better UX during font loading. - Adjust check icon positioning in FontCard to be absolute for consistent UI. - Remove unnecessary class in GoogleFontPicker's text input. - Add border and rounded styling to the font list container in GoogleFontPicker. - Simplify computed property for enrichedFonts in GoogleFontPicker. - Implement inline font style preview in FormCustomization component. --------- Co-authored-by: Frank <csskfaves@gmail.com> Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
parent
2a3aad6c62
commit
1ac71ecf8b
|
|
@ -83,4 +83,6 @@ CADDY_AUTHORIZED_IPS=
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
GOOGLE_REDIRECT_URL=http://localhost:3000/settings/connections/callback/google
|
GOOGLE_REDIRECT_URL=http://localhost:3000/settings/connections/callback/google
|
||||||
|
|
||||||
|
GOOGLE_FONTS_API_KEY=
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class FontsController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
return \Cache::remember('google_fonts', 60 * 60, function () {
|
||||||
|
$url = "https://www.googleapis.com/webfonts/v1/webfonts?sort=popularity&key=" . config('services.google_fonts_api_key');
|
||||||
|
$response = Http::get($url);
|
||||||
|
if ($response->successful()) {
|
||||||
|
$fonts = collect($response->json()['items'])->filter(function ($font) {
|
||||||
|
return !in_array($font['category'], ['monospace']);
|
||||||
|
})->map(function ($font) {
|
||||||
|
return $font['family'];
|
||||||
|
})->toArray();
|
||||||
|
return response()->json($fonts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
|
||||||
'visibility' => ['required', Rule::in(Form::VISIBILITY)],
|
'visibility' => ['required', Rule::in(Form::VISIBILITY)],
|
||||||
|
|
||||||
// Customization
|
// Customization
|
||||||
|
'font_family' => 'string|nullable',
|
||||||
'theme' => ['required', Rule::in(Form::THEMES)],
|
'theme' => ['required', Rule::in(Form::THEMES)],
|
||||||
'width' => ['required', Rule::in(Form::WIDTHS)],
|
'width' => ['required', Rule::in(Form::WIDTHS)],
|
||||||
'size' => ['required', Rule::in(Form::SIZES)],
|
'size' => ['required', Rule::in(Form::SIZES)],
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,9 @@ class Form extends Model implements CachableAttributes
|
||||||
|
|
||||||
public const DARK_MODE_VALUES = ['auto', 'light', 'dark'];
|
public const DARK_MODE_VALUES = ['auto', 'light', 'dark'];
|
||||||
|
|
||||||
public const SIZES = ['sm','md','lg'];
|
public const SIZES = ['sm', 'md', 'lg'];
|
||||||
|
|
||||||
public const BORDER_RADIUS = ['none','small','full'];
|
public const BORDER_RADIUS = ['none', 'small', 'full'];
|
||||||
|
|
||||||
public const THEMES = ['default', 'simple', 'notion'];
|
public const THEMES = ['default', 'simple', 'notion'];
|
||||||
|
|
||||||
|
|
@ -53,6 +53,7 @@ class Form extends Model implements CachableAttributes
|
||||||
'visibility',
|
'visibility',
|
||||||
|
|
||||||
// Customization
|
// Customization
|
||||||
|
'font_family',
|
||||||
'custom_domain',
|
'custom_domain',
|
||||||
'size',
|
'size',
|
||||||
'border_radius',
|
'border_radius',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col p-3 rounded-md shadow border-gray-200 border-[0.5px] justify-between w-full cursor-pointer hover:ring ring-blue-300 relative"
|
||||||
|
:class="{'ring': isSelected }"
|
||||||
|
@click="$emit('select-font')"
|
||||||
|
>
|
||||||
|
<template v-if="isVisible">
|
||||||
|
<link
|
||||||
|
:href="getFontUrl"
|
||||||
|
rel="stylesheet"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-lg mb-3 font-normal"
|
||||||
|
:style="{ 'font-family': `${fontName} !important` }"
|
||||||
|
>
|
||||||
|
The quick brown fox jumped over the lazy dog
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-wrap gap-2 mb-3"
|
||||||
|
>
|
||||||
|
<USkeleton
|
||||||
|
class="h-5 w-full"
|
||||||
|
/>
|
||||||
|
<USkeleton
|
||||||
|
class="h-5 w-3/4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-gray-400 flex justify-between">
|
||||||
|
<p class="text-xs">
|
||||||
|
{{ fontName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
v-if="isSelected"
|
||||||
|
name="heroicons:check-circle-16-solid"
|
||||||
|
class="w-5 h-5 text-nt-blue absolute bottom-4 right-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineEmits } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
fontName: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isSelected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isVisible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select-font'])
|
||||||
|
|
||||||
|
const getFontUrl = computed(() => {
|
||||||
|
const family = props.fontName.replace(/ /g, '+')
|
||||||
|
return `https://fonts.googleapis.com/css?family=${family}:wght@400&display=swap`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
<template>
|
||||||
|
<modal
|
||||||
|
:show="show"
|
||||||
|
:compact-header="true"
|
||||||
|
@close="$emit('close')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon
|
||||||
|
name="ci:font"
|
||||||
|
class="w-10 h-10 text-blue"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #title>
|
||||||
|
Google fonts
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="loading">
|
||||||
|
<Loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<text-input
|
||||||
|
v-model="search"
|
||||||
|
|
||||||
|
name="search"
|
||||||
|
placeholder="Search fonts"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="scrollContainer"
|
||||||
|
class="grid grid-cols-3 gap-2 p-5 mb-5 overflow-y-scroll max-h-[24rem] border rounded-md"
|
||||||
|
>
|
||||||
|
<FontCard
|
||||||
|
v-for="(fontName, index) in enrichedFonts"
|
||||||
|
:key="fontName"
|
||||||
|
:ref="el => setFontRef(el, index)"
|
||||||
|
:font-name="fontName"
|
||||||
|
:is-visible="visible[index]"
|
||||||
|
:is-selected="selectedFont === fontName"
|
||||||
|
@select-font="selectedFont = fontName"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<UButton
|
||||||
|
size="md"
|
||||||
|
color="white"
|
||||||
|
class="mr-2"
|
||||||
|
@click="$emit('apply', null)"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
size="md"
|
||||||
|
:disabled="!selectedFont"
|
||||||
|
block
|
||||||
|
class="flex-1"
|
||||||
|
@click="$emit('apply', selectedFont)"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineEmits } from "vue"
|
||||||
|
import Fuse from "fuse.js"
|
||||||
|
import { refDebounced, useElementVisibility } from "@vueuse/core"
|
||||||
|
import FontCard from './FontCard.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close','apply'])
|
||||||
|
const loading = ref(false)
|
||||||
|
const fonts = ref([])
|
||||||
|
const selectedFont = ref(props.font || null)
|
||||||
|
const search = ref("")
|
||||||
|
const debouncedSearch = refDebounced(search, 500)
|
||||||
|
const scrollContainer = ref(null)
|
||||||
|
const fontRefs = new Map()
|
||||||
|
const visible = ref([])
|
||||||
|
|
||||||
|
const setFontRef = (el, index) => {
|
||||||
|
if (el) fontRefs.set(index, el)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializeVisibilityTracking = async () => {
|
||||||
|
await nextTick() // Ensure DOM has been fully updated
|
||||||
|
fontRefs.forEach((el, index) => {
|
||||||
|
const visibility = useElementVisibility(el, {
|
||||||
|
root: scrollContainer.value,
|
||||||
|
threshold: 0.1
|
||||||
|
})
|
||||||
|
watch(
|
||||||
|
() => visibility.value,
|
||||||
|
(isVisible) => {
|
||||||
|
if (isVisible) {
|
||||||
|
visible.value[index] = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchFonts = async () => {
|
||||||
|
if (props.show) {
|
||||||
|
selectedFont.value = props.font || null
|
||||||
|
loading.value = true
|
||||||
|
opnFetch('/fonts/').then((data) => {
|
||||||
|
fonts.value = data || []
|
||||||
|
loading.value = false
|
||||||
|
initializeVisibilityTracking()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(() => props.show, fetchFonts)
|
||||||
|
|
||||||
|
|
||||||
|
const enrichedFonts = computed(() => {
|
||||||
|
if (search.value === "" || search.value === null) {
|
||||||
|
return fonts.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuze search
|
||||||
|
const fuse = new Fuse(Object.values(fonts.value))
|
||||||
|
return fuse.search(debouncedSearch.value).map((res) => {
|
||||||
|
return res.item
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -2,7 +2,14 @@
|
||||||
<div
|
<div
|
||||||
v-if="form"
|
v-if="form"
|
||||||
class="open-complete-form"
|
class="open-complete-form"
|
||||||
|
:style="{ '--font-family': form.font_family }"
|
||||||
>
|
>
|
||||||
|
<link
|
||||||
|
v-if="adminPreview && form.font_family"
|
||||||
|
rel="stylesheet"
|
||||||
|
:href="getFontUrl"
|
||||||
|
>
|
||||||
|
|
||||||
<h1
|
<h1
|
||||||
v-if="!isHideTitle"
|
v-if="!isHideTitle"
|
||||||
class="mb-4 px-2"
|
class="mb-4 px-2"
|
||||||
|
|
@ -249,6 +256,11 @@ export default {
|
||||||
},
|
},
|
||||||
isHideTitle () {
|
isHideTitle () {
|
||||||
return this.form.hide_title || (import.meta.client && window.location.href.includes('hide_title=true'))
|
return this.form.hide_title || (import.meta.client && window.location.href.includes('hide_title=true'))
|
||||||
|
},
|
||||||
|
getFontUrl() {
|
||||||
|
if(!this.form || !this.form.font_family) return null
|
||||||
|
const family = this.form?.font_family.replace(/ /g, '+')
|
||||||
|
return `https://fonts.googleapis.com/css?family=${family}:wght@400,500,700,800,900&display=swap`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -330,6 +342,9 @@ export default {
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.open-complete-form {
|
.open-complete-form {
|
||||||
|
* {
|
||||||
|
font-family: var(--font-family) !important;
|
||||||
|
}
|
||||||
.form-description, .nf-text {
|
.form-description, .nf-text {
|
||||||
ol {
|
ol {
|
||||||
@apply list-decimal list-inside;
|
@apply list-decimal list-inside;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,24 @@
|
||||||
label="Form Theme"
|
label="Form Theme"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<label class="text-gray-700 font-medium text-sm">Font Style</label>
|
||||||
|
<v-button
|
||||||
|
color="white"
|
||||||
|
class="w-full mb-4"
|
||||||
|
size="small"
|
||||||
|
@click="showGoogleFontPicker = true"
|
||||||
|
>
|
||||||
|
<span :style="{ 'font-family': (form.font_family?form.font_family+' !important':null) }">
|
||||||
|
{{ form.font_family || 'Default' }}
|
||||||
|
</span>
|
||||||
|
</v-button>
|
||||||
|
<GoogleFontPicker
|
||||||
|
:show="showGoogleFontPicker"
|
||||||
|
:font="form.font_family || null"
|
||||||
|
@close="showGoogleFontPicker=false"
|
||||||
|
@apply="onApplyFont"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex space-x-4 justify-stretch">
|
<div class="flex space-x-4 justify-stretch">
|
||||||
<select-input
|
<select-input
|
||||||
name="size"
|
name="size"
|
||||||
|
|
@ -173,12 +191,14 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useWorkingFormStore } from "../../../../../stores/working_form"
|
import { useWorkingFormStore } from "../../../../../stores/working_form"
|
||||||
import EditorOptionsPanel from "../../../editors/EditorOptionsPanel.vue"
|
import EditorOptionsPanel from "../../../editors/EditorOptionsPanel.vue"
|
||||||
|
import GoogleFontPicker from "../../../editors/GoogleFontPicker.vue"
|
||||||
import ProTag from "~/components/global/ProTag.vue"
|
import ProTag from "~/components/global/ProTag.vue"
|
||||||
|
|
||||||
const workingFormStore = useWorkingFormStore()
|
const workingFormStore = useWorkingFormStore()
|
||||||
const form = storeToRefs(workingFormStore).content
|
const form = storeToRefs(workingFormStore).content
|
||||||
const isMounted = ref(false)
|
const isMounted = ref(false)
|
||||||
const confetti = useConfetti()
|
const confetti = useConfetti()
|
||||||
|
const showGoogleFontPicker = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
isMounted.value = true
|
isMounted.value = true
|
||||||
|
|
@ -190,4 +210,9 @@ const onChangeConfettiOnSubmission = (val) => {
|
||||||
confetti.play()
|
confetti.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onApplyFont = (val) => {
|
||||||
|
form.value.font_family = val
|
||||||
|
showGoogleFontPicker.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export const initForm = (defaultValue = {}, withDefaultProperties = false) => {
|
||||||
properties: withDefaultProperties ? getDefaultProperties() : [],
|
properties: withDefaultProperties ? getDefaultProperties() : [],
|
||||||
|
|
||||||
// Customization
|
// Customization
|
||||||
|
font_family: null,
|
||||||
theme: "default",
|
theme: "default",
|
||||||
width: "centered",
|
width: "centered",
|
||||||
dark_mode: "auto",
|
dark_mode: "auto",
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,39 @@ const pageMeta = computed(() => {
|
||||||
}
|
}
|
||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getFontUrl = computed(() => {
|
||||||
|
if(!form.value || !form.value.font_family) return null
|
||||||
|
const family = form.value.font_family.replace(/ /g, '+')
|
||||||
|
return `https://fonts.googleapis.com/css?family=${family}:wght@400,500,700,800,900&display=swap`
|
||||||
|
})
|
||||||
|
|
||||||
|
const headLinks = computed(() => {
|
||||||
|
const links = []
|
||||||
|
if (form.value && form.value.font_family) {
|
||||||
|
links.push({
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: getFontUrl.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (pageMeta.value.page_favicon) {
|
||||||
|
links.push({
|
||||||
|
rel: 'icon', type: 'image/x-icon',
|
||||||
|
href: pageMeta.value.page_favicon
|
||||||
|
})
|
||||||
|
links.push({
|
||||||
|
rel: 'apple-touch-icon',
|
||||||
|
type: 'image/png',
|
||||||
|
href: pageMeta.value.page_favicon
|
||||||
|
})
|
||||||
|
links.push({
|
||||||
|
rel: 'shortcut icon',
|
||||||
|
href: pageMeta.value.page_favicon
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return links
|
||||||
|
})
|
||||||
|
|
||||||
useOpnSeoMeta({
|
useOpnSeoMeta({
|
||||||
title: () => {
|
title: () => {
|
||||||
if (pageMeta.value.page_title) {
|
if (pageMeta.value.page_title) {
|
||||||
|
|
@ -209,21 +242,7 @@ useHead({
|
||||||
}
|
}
|
||||||
return titleChunk ? `${titleChunk} - OpnForm` : 'OpnForm'
|
return titleChunk ? `${titleChunk} - OpnForm` : 'OpnForm'
|
||||||
},
|
},
|
||||||
link: pageMeta.value.page_favicon ? [
|
link: headLinks.value,
|
||||||
{
|
|
||||||
rel: 'icon', type: 'image/x-icon',
|
|
||||||
href: pageMeta.value.page_favicon
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: 'apple-touch-icon',
|
|
||||||
type: 'image/png',
|
|
||||||
href: pageMeta.value.page_favicon
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: 'shortcut icon',
|
|
||||||
href: pageMeta.value.page_favicon
|
|
||||||
}
|
|
||||||
] : {},
|
|
||||||
meta: pageMeta.value.page_favicon ? [
|
meta: pageMeta.value.page_favicon ? [
|
||||||
{
|
{
|
||||||
name: 'apple-mobile-web-app-capable',
|
name: 'apple-mobile-web-app-capable',
|
||||||
|
|
|
||||||
|
|
@ -69,5 +69,7 @@ return [
|
||||||
'client_id' => env('GOOGLE_CLIENT_ID'),
|
'client_id' => env('GOOGLE_CLIENT_ID'),
|
||||||
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
||||||
'redirect' => env('GOOGLE_REDIRECT_URL'),
|
'redirect' => env('GOOGLE_REDIRECT_URL'),
|
||||||
]
|
],
|
||||||
|
|
||||||
|
'google_fonts_api_key' => env('GOOGLE_FONTS_API_KEY'),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class () extends Migration {
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('forms', function (Blueprint $table) {
|
||||||
|
$table->string('font_family')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('forms', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['font_family']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -313,6 +313,9 @@ Route::prefix('content')->name('content.')->group(function () {
|
||||||
|
|
||||||
Route::get('/sitemap-urls', [\App\Http\Controllers\SitemapController::class, 'index'])->name('sitemap.index');
|
Route::get('/sitemap-urls', [\App\Http\Controllers\SitemapController::class, 'index'])->name('sitemap.index');
|
||||||
|
|
||||||
|
// Fonts
|
||||||
|
Route::get('/fonts', [\App\Http\Controllers\FontsController::class, 'index'])->name('fonts.index');
|
||||||
|
|
||||||
// Templates
|
// Templates
|
||||||
Route::prefix('templates')->group(function () {
|
Route::prefix('templates')->group(function () {
|
||||||
Route::get('/', [TemplateController::class, 'index'])->name('templates.index');
|
Route::get('/', [TemplateController::class, 'index'])->name('templates.index');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue